Skip to content

fix(provider): use a larger stall budget while a Cursor tool is in flight - #86

Merged
justin-carper merged 2 commits into
mainfrom
fix/stall-watchdog-long-tools
Jul 30, 2026
Merged

fix(provider): use a larger stall budget while a Cursor tool is in flight#86
justin-carper merged 2 commits into
mainfrom
fix/stall-watchdog-long-tools

Conversation

@justin-carper

@justin-carper justin-carper commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

A turn running a long tool call dies with:

Cursor run stalled (no events for 60000ms)

That message comes from the anyEvent === true branch of onStall — it's terminal: the run is cancelled and the turn is lost, with no retry.

The stream watchdog re-arms only from push(), which fires for the ten update types mapped in onDelta's switch. Between tool-call-started and tool-call-completed a long shell command, build, or test suite streams nothing at all, so a perfectly healthy run trips the 60s budget. Raising the constant alone doesn't fix it — any fixed ceiling still kills a long-enough tool, while making a genuinely wedged run take longer to surface.

The switch also has no default case, so any update type the plugin doesn't model is dropped and never counts as liveness.

Fix

Two budgets. armWatchdog() picks based on whether any tool call is open:

Env Default Applies
OPENCODE_CURSOR_STALL_MS 120000 (was 60000) idle — no tool call open. 0 disables the whole watchdog
OPENCODE_CURSOR_TOOL_STALL_MS 600000 while ≥1 tool call is in flight. 0 disables just this bound

A tool-phase stall stays terminal but now names the culprit:

Cursor run stalled (no events for 600000ms; tool "shell" still in flight).
Raise OPENCODE_CURSOR_TOOL_STALL_MS (or set 0 to disable) if this legitimately runs longer.

Re-arm on raw SDK activity. armWatchdog() is called for every onDelta update, not just mapped ones, so progress/heartbeat types count as liveness. It runs after the switch so it observes post-mutation openTools state and always selects the correct budget.

Reconcile openTools. Cleared on turn-ended and on a forced resend. Without this, a dropped or differently-keyed completion pins the turn to the 10-minute budget and names a tool that already finished.

Env parsing hardening. Number("abc") is NaN, and NaN <= 0 is false — so the old guard passed and setTimeout(fn, NaN) fired immediately, stalling every turn. Non-finite values now fall back to the default. An empty string still yields 0 (disabled), preserving the existing escape hatch.

Testing

npm run typecheck clean · npm test 367 passed · npm run build succeeds.

10 new tests, including 3 explicit negative controls that prove the positive results come from the fix rather than the harness (e.g. the same timeline minus the intervening update does stall).

Each production change was mutation-tested — reverted individually to confirm a test actually fails:

Reverted Result
two-budget selection → always idle 2 tests fail
post-switch armWatchdog() 2 tests fail
openTools.delete on completion 1 fails
openTools.clear() on turn-ended 1 fails
Number.isFinite guard 1 fails
empty string → fallback 1 fails
idle default 12000060000 1 fails

The 3 pre-existing watchdog tests are unchanged and still pass.

Scope

Deliberately excludes the Cursor SDK log-line leak — #85 covers that, and its approach (routing to client.app.log, plus sidecar coverage) is the better one.

Two suggestions for #85 from investigating the same area:

Known limitations

  • No absolute per-turn ceiling. Re-arming on any update means a runtime emitting heartbeats while genuinely wedged could re-arm indefinitely. Left out deliberately: there's no evidence Cursor emits unmapped update types today, so a third timeout knob would be speculative. OPENCODE_CURSOR_DEBUG=1 already counts every update type if this ever needs revisiting.
  • await runHolder.run?.cancel() in failTerminal is unbounded. Pre-existing, but the tool-phase path makes it more reachable — a wedged run is exactly the case where cancel() may not settle. Worth its own change.
  • Not validated against a live agent. No CURSOR_API_KEY was available. The tool-phase-vs-idle-phase diagnosis is inferred from the error string and the reported symptom (a long-running command); the fix covers both phases so either diagnosis is handled, but an end-to-end run with a multi-minute tool would confirm it.

…ight

The stream watchdog re-armed only from push(), which fires for the ten
mapped update types. A long shell command, build, or test suite streams
nothing between tool-call-started and tool-call-completed, so a healthy
run was cancelled at the 60s budget and the turn lost.

Split the budget in two: an idle budget (OPENCODE_CURSOR_STALL_MS,
raised 60000 -> 120000) and a tool-phase budget applied while at least
one tool call is open (OPENCODE_CURSOR_TOOL_STALL_MS, default 600000,
0 disables just that bound). A tool-phase stall stays terminal and now
names the tool still in flight.

Also re-arm on any raw SDK update rather than only mapped ones, so
progress/heartbeat types the plugin does not model still count as
liveness. The re-arm runs after the switch so it observes post-mutation
openTools state and always selects the correct budget.

Reconcile openTools on turn-ended and on the forced resend: a dropped
or differently-keyed completion would otherwise pin the turn to the
10-minute budget and name a tool that had already finished.

Harden env parsing: Number("abc") is NaN and NaN <= 0 is false, so the
old guard passed and setTimeout(fn, NaN) fired immediately, stalling
every turn. Non-finite values now fall back to the default; an empty
string still disables, preserving the existing escape hatch.
A setTimeout delay is stored as a signed 32-bit int, so a value above
2147483647 overflows and Node silently clamps it to 1ms. envMs rejected
NaN and Infinity but passed every other finite value straight through, so
OPENCODE_CURSOR_TOOL_STALL_MS=999999999999 stalled every tool-bearing turn
within milliseconds while reporting "no events for 999999999999ms".

The tool-phase stall message tells operators to raise that same variable,
so the trap was reachable by following the plugin's own advice.

Both budgets are now capped at 2147483647. Verified against a live agent:
with the over-large budget set, a 150s shell call died after 5.6s before
this change and completes normally after it.
@justin-carper
justin-carper force-pushed the fix/stall-watchdog-long-tools branch from 80f7409 to 0556e8c Compare July 30, 2026 17:57
@justin-carper
justin-carper merged commit 47bb2db into main Jul 30, 2026
6 checks passed
@justin-carper
justin-carper deleted the fix/stall-watchdog-long-tools branch July 30, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant